Passa al contenuto principale
Versione: 2025-26

Esercitazione 1

La caratteristica principale del programmare in assembler è che le operazioni a disposizione sono solo quelle messe a disposizione dal processore. Infatti, l'assemblatore fa molto poco: dopo aver sostituito le varie label con indirizzi, traduce ciascuna istruzione, nell'ordine in cui sono presenti, nel diretto corrispettivo binario (il cosiddetto linguaggio macchina). Questo binario è poi eseguito direttamente dal processore. Dato un algoritmo per risolvere un problema, i passi base di questo algoritmo devono essere istruzioni comprese dal processore, e siamo quindi limitati dall'hardware e le sue caratteristiche.

Per esempio, dato che il processore non supporta mov da un indirizzo di memoria a un altro indirizzo di memoria, non possiamo fare questa operazione con una sola istruzione: dobbiamo invece scomporre in mov src, %eax mov %eax, dest, assicurandoci nel frattempo di non aver perso alcun dato importante prima contenuto in %eax.

Per svolgere gli esercizi, bisogna quindi imparare a scomporre strutture di programmazione già note (come if-then-else, cicli, accesso a vettore) nelle operazioni elementari messe ad disposizione dal processore, usando il limitato numero di registri a disposizione al posto di variabili, e tenendo presente quali operazioni da fare con quali dati, senza un sistema di tipizzazione ad aiutarci.

Premesse per programmi nell'ambiente del corso

Unica eccezione alla logica di cui sopra sono i sottoprogrammi di ingresso/uscita, forniti tramite utility.s: questi interagiscono con il terminale tramite il kernel usando il meccanismo delle interruzioni, concetti che avrete il tempo di esplorare in corsi successivi. Qui ci limiteremo a seguirne le specifiche, documentate qui, per leggere o stampare a video numeri, caratteri, o stringhe. Per esempio, parte di queste specifiche è l'uso del carattere di ritorno carrello \r come terminatore di stringa. Per usarli, però, va istruito l'assemblatore di aggiungere questi sottoprogrammi al nostro codice, con

.include "./files/utility.s"

Un altro aspetto importante è dove comincia e finisce il nostro programma: nell'ambiente del corso, il punto di ingresso è la label _main e quello di uscita è la corrispondente istruzione ret. Per motivi di debugging, che saranno chiari più avanti, si tende a cominciare il programma con una istruzione nop.

Inoltre, la distinzione tra zona .data e .text è importante. Dato che durante l'esecuzione sono entrambi caricati in memoria, per motivi di sicurezza il kernel Linux ci impedirà di eseguire indirizzi in .data o di scrivere in indirizzi in .text. Dimenticarsi di dichiararli porta a eccezioni durante l'esecuzione.

Infine, l'assemblatore non vede di buon occhio la mancanza di una riga vuota alla fine del file. Per evitare messaggi di warning inutili, meglio aggiungerla.

Detto ciò, possiamo quindi comprendere il programma di test, che non fa che stampare "Ok." a terminale e poi termina:

.include "./files/utility.s"

.data
messaggio: .ascii "Ok.\r"

.text
_main:
nop
lea messaggio, %ebx
call outline
ret

Queste premesse si applicano solo a questo corso

Le istruzioni di questa sezione sono relative all'ambiente del corso. La direttiva .include "./files/utility.s" ricopia il codice del file utility.s, fornito nell'ambiente del corso. Le specifiche dei sottoprogrammi (uso dei registri, \r come carattere di terminazione, etc.) sono conseguenza di come è scritto questo codice, che ha a che fare con scelte fatte da noi, per esempio per mantenere la retrocompatibilità con il vecchio ambiente DOS utilizzato precedentemente sempre in questo corso. L'uso di _main e ret (peraltro, senza alcun valore di ritorno), così come il comportamento del terminale, sono anche questi relativi all'ambiente usato.

Non sono assolutamente concetti validi in generale, per altri assembler e altri ambienti. Tenete questo ben presente nel caso vi avvicinaste allo sviluppo di assembler in altri contesti.

Esercizio 1.1

Partiamo da un esercizio con le seguenti specifiche

1. Leggere messaggio da terminale.
2. Convertire le lettere minuscole in maiuscolo.
3. Stampare messaggio modificato.

Per i passi 1 e 3 possiamo usare i sottoprogrammi di utility inline e outline (documentazione). Cominciamo riservando in memoria, nella sezione data, spazio per le due stringhe.

.data

msg_in: .fill 80, 1, 0
msg_out: .fill 80, 1, 0

Per la lettura useremo

mov $80, %cx
lea msg_in, %ebx
call inline

Per la scrittura invece useremo

lea msg_out, %ebx
call outline

Quel che manca ora è il punto 2. Dobbiamo (capire come) fare diverse cose:

  • ricopiare msg_in in msg_out carattere per carattere
  • controllare tale carattere, per capire se è una lettera minuscola
    • se sì, cambiare tale carattere nella corrispondente maiuscola

Cominciamo dal capire il primo punto, cioè come ricopiare il messaggio, ignorando per ora la gestione dei caratteri minuscoli.

Come scorrere i due vettori? Abbiamo due opzioni: usare un indice per accesso indicizzato, o due puntatori da incrementare. Anche sulla condizione di terminazione abbiamo due opzioni: fermarsi dopo aver processato il carattere di ritorno carrello \r, o dopo aver processato 80 caratteri.

Per questo esercizio, scegliamo la prima opzione per entrambe le scelte. Se usassimo C, scriveremmo qualcosa simile a questo:

char[] msg_in, msg_out;
...
int i = 0;
char c;
do {
c = msg_in[i];
// si può trasformare c qui
msg_out[i] = c;
i++;
} while (c != '\r');

In assembler, questo si può scrivere così:

    lea msg_in, %esi
lea msg_out, %edi
mov $0, %ecx
loop:
movb (%esi, %ecx), %al
# si può trasformare %al qui
movb %al, (%edi, %ecx)
inc %ecx
cmp $0x0d, %al # $0x0d è equivalente a $'\r'
jne loop

Ci sono diversi aspetti da sottolineare. Il primo è che nell'accesso con indice, a differenza del C, abbiamo completo controllo sia di come è calcolato l'indirizzo di accesso, sia sulla dimensione della lettura in memoria.

Prendiamo il caso di movb (%esi, %ecx), %al. Ricordiamo che il formato dell'indirizzazione con indice è offset(%base, %indice, scala), dove l'indirizzo è calcolato come offset + %base + (%indice * scala). Dunque (%esi, %ecx) è, implicitamente, 0(%esi, %ecx, 1), dove l'1 indica il fatto che ci spostiamo di un byte alla volta. Dato l'indirizzo, però, in abbiamo controllo di quanti byte leggere, questa volta tramite il suffisso b o, implicitamente, tramite la dimensione del registro di destinazione %al.

In C, tutti questi aspetti sono gestiti automaticamente come conseguenza dell'uso del tipo char, che è appunto di 1 byte. In assembler, dobbiamo starci attenti noi. Infatti, il processore esegue le istruzioni senza alcun controllo (né cognizione) su che tipo di dato stiamo cercando di accedere e dove.

Prima di passare al resto del punto 2, vale la pena provare a comporre il programma così com'è, testarlo ed eseguirlo. Infatti, è sempre una buona idea trovare i bug quanto prima, e quanto più è semplice il codice scritto tanto più lo è trovare la fonte del bug. Il codice scritto finora è scaricabile qui, e questo è l'output che otteniamo provando ad assemblarlo ed eseguirlo.

PS /mnt/c/reti_logiche/assembler> ./assemble.ps1 ./esercitazioni/1/maiusc_p1.s
PS /mnt/c/reti_logiche/assembler> ./esercitazioni/1/maiusc_p1
questo E' UN test
questo E' UN test
PS /mnt/c/reti_logiche/assembler>

Passiamo adesso ai punti ignorati prima, ossia controllare che il carattere letto sia una minuscola, e nel caso cambiarla in maiuscola. Per controllare che un carattere sia una lettera minuscola, ci basta ricordare che i caratteri ASCII hanno una codifica binaria ordinata: char c è minuscola se c >= 'a' && c <= 'z'.

Per cambiare invece una minuscola e maiuscola, notiamo sempre dalla tabella ASCII che, per lo stesso motivo, la distanza tra 'a' e 'A' è la stessa di qualunque altra coppia di maiuscola-minuscola. Tale distanza è 32: ci basta infatti sottrarre 32 a una minuscola per ottenere la corrispondente maiuscola, e aggiungere 32 per fare il contrario. Guardando alla rappresentazione in base 2, notiamo che l'operazione è (non per caso) ancora più semplice: essendo 32=2532 = 2^5, si tratta di mettere il bit in posizione 5 a 0 o 1, usando and, or o xor con maschere appropriate.

Detto ciò, il codice C diventa:

char[] msg_in, msg_out;
...
int i = 0;
char c;
do {
c = msg_in[i];
if(c >= 'a' && c <= 'z')
c = c & 0xdf;
msg_out[i] = c;
i++;
} while (c != '\r');

La notazione esadecimale 0xdf corrisponde a 1101 1111. Fare un and con tale maschera lascia tutti i bit invariati tranne quello in posizione 5, che viene resettato. Per esempio

      0x63 0110 0011  'c'
AND 0xdf 1101 1111
--------------
= 0x43 0101 0011 'C'
Il controllo non è opzionale

Domanda: se vogliamo che tutte le lettere siano maiuscole, non basta resettare il bit 5 a prescindere, e non fare il controllo?

Risposta: no, perché ci sono altri caratteri ASCII con il bit 5 a 1 che non sono affatto lettere. Per esempio, il carattere spazio di codifica 0x20.

Questo si traduce nel seguente assembler:

    lea msg_in, %esi
lea msg_out, %edi
mov $0, %ecx
loop:
movb (%esi, %ecx), %al
cmp $'a', %al
jb post_check
cmp $'z', %al
ja post_check

and $0xdf, %al # 1101 1111 -> l'and resetta il bit 5

post_check:
movb %al, (%edi, %ecx)
inc %ecx
cmp $0x0d, %al
jne loop

Notiamo che le due condizione nell'if vanno rimaneggiate per essere tradotte da C ad assembler, infatti saltiamo a post_check, dopo l'istruzione di conversione, se le condizioni non sono verificate.

Il codice finale è quindi il seguente, scaricabile qui come file sorgente.

.include "./files/utility.s"

.data
msg_in: .fill 80, 1, 0
msg_out: .fill 80, 1, 0

.text
_main:
nop
punto_1:
mov $80, %cx
lea msg_in, %ebx
call inline
nop
punto_2:
lea msg_in, %esi
lea msg_out, %edi
mov $0, %ecx
loop:
movb (%esi, %ecx), %al
cmp $'a', %al
jb post_check
cmp $'z', %al
ja post_check
and $0xdf, %al
post_check:
movb %al, (%edi, %ecx)
inc %ecx
cmp $0x0d, %al
jne loop
punto_3:
lea msg_out, %ebx
call outline
nop
fine:
ret

Le label punto_1, punto_2, punto_3 e fine sono, come è facile verificare, del tutto opzionali. Sono però utili ai fini del debugging, che presentiamo ora.

Sono da notare le nop aggiunte prima tra le call alle righe 13 e 33 e le successive label: queste sono un workaround per ovviare a un problema di gdb, che spiegherò più avanti.

Uso del debugger

Debugging is like being the detective in a crime movie where you are also the murderer.
Filipe Fortes

La parola debugger suggerisce da sé che sia uno strumento per rimuovere bug ma, purtroppo, questo non vuol dire che lo strumento li rimuove da solo. Infatti, quello in cui ci è utile il debugger è trovare i bug, seguendo l'esecuzione del programma passo passo e controllando il suo stato per capire dov'è che il suo comportamento differisce da quanto ci aspettiamo. Da lì, spesso indagando a ritroso e con un po' di intuito, si può trovare le istruzioni incriminate e correggerle.

Uno strumento per essere più efficienti

Domanda: sembra complicato, non è più facile rileggere il codice?

Risposta: sì, lo è. Ma, in genere, quando basta rileggere è perché si è fatto un errore di digitazione, non di ragionamento. Saper usare il debugger significa sapersi tirare fuori velocemente da errori che richiederebbero rileggere a fondo tutto il codice.

Il debugger che usiamo è gdb, che funziona da linea di comando. Questo parte da un binario eseguibile, che verrà eseguito passo passo come da noi indicato.

Per semplicità d'uso, l'ambiente ha uno script debug.ps1, da lanciare con

./debug.ps1 nome-eseguibile

Lo script fa dei controlli, tra cui assicurarsi che si sia passato l'eseguibile e non il sorgente, lancia il debugger con alcuni comandi tipici già inseriti (imposta un breakpoint a _main e lancia il programma), e ne definisce altri per comodità d'uso (rr e qq, per riavviare il programma o uscire senza dare conferma).

Vediamo come usarlo, lanciando il debugger sul programma realizzato nell'esercizio precedente. Dopo un sezione di presentazione del programma, abbiamo del testo del tipo

Breakpoint 1, _main () at /mnt/c/reti_logiche/assembler/lezioni/1/maiusc.s:9
9 nop
(gdb)

Un breakpoint è un punto del programma, in genere una linea di codice, dove si desidera che il debugger fermi l'esecuzione. Avendo impostato il primo breakpoint a _main, vediamo infatti che il programma si ferma alla prima istruzione relativa, che è appunto la nop. Importante: il debugger si ferma prima dell'esecuzione della riga indicata.

Vediamo poi che il debugger richiede input: infatti possiamo interagire con il debugger solo quando il programma è fermo. Possiamo fare tre cose in particolare:

  • Osservare il contenuto di registri e indirizzi di memoria (info registers e x),
  • Impostare nuovi breakpoints (break),
  • Continuare l'esecuzione in modo controllato (step e next) o fino al prossimo breakpoint (continue)

Vediamoli in azione. Cominciamo con il proseguire fino alla riga 13.

Breakpoint 1, _main () at /mnt/c/reti_logiche/assembler/lezioni/1/maiusc.s:9
9 nop
(gdb) step
punto_1 () at /mnt/c/reti_logiche/assembler/lezioni/1/maiusc.s:11
11 mov $80, %cx
(gdb) s
12 lea msg_in, %ebx
(gdb) s
13 call inline
(gdb)

Notiamo che gdb accetta sia comandi per esteso sia abbreviati, per esempio per step va bene anche s. Con questi 3 step, abbiamo eseguito le prime tre istruzioni ma non la call a riga 13. Possiamo controllare lo stato dei registri usando info registers, abbreviabile con i r.

(gdb) i r
eax 0x66 102
ecx 0x50 80
edx 0x2d 45
ebx 0x56559066 1448448102
esp 0xffffc06c 0xffffc06c
ebp 0xffffc078 0xffffc078
esi 0xf7fb2000 -134537216
edi 0xf7fb2000 -134537216
eip 0x5655676e 0x5655676e <punto_1+10>
eflags 0x282 [ SF IF ]
cs 0x23 35
ss 0x2b 43
ds 0x2b 43
es 0x2b 43
fs 0x0 0
gs 0x63 99
(gdb)

Notare: è un caso trovare i registri già inizializzati a 0, come qui mostrato.

Questo ci da info su diversi registri, molti dei quali non ci interessano. Possiamo specificare quali registri vogliamo, anche di dimensioni minori di 32 bit.

(gdb) i r cx ebx
cx 0x50 80
ebx 0x56559066 1448448102
(gdb)

La prossima istruzione, se lasciamo il programma eseguire, è una call. In questo caso, abbiamo due scelte: proseguire nella chiamata al sottoprogramma (andando quindi alle istruzioni di inline, definite in utility.s), od oltre la chiamata, andando quindi direttamente alla riga 14. Questa è la differenza fra step e next: step prosegue dentro i sottoprogrammi, mentre next prosegue finché il sottoprogramma non ritorna.

È qui però che è rilevante la presenza della nop aggiunta a riga 14, prima di parte_2. next infatti continua fino alla prossima istruzione della sezione corrente del codice, che è in questo caso punto_1. Se però tale sezione termina subito dopo la call, e non esiste quindi una successiva istruzione nella stessa sezione, allora usando next il programma continuerà fino alla terminazione. Aggiungere la nop ovvia al problema essendo una successiva istruzione ancora parte di punto_1.

13          call inline
(gdb) n
questo e' un test
14 nop
(gdb)

Da notare che "questo e' un test" è proprio l'input inserito da tastiera durante l'esecuzione di inline.

Eseguire il programma un'istruzione alla volta può risultare molto lento. Per esempio, quando vogliamo osservare cosa succede a una particolare iterazione di un loop. Per questo ci aiutano break e continue. Nell'esempio che segue, sono usati per raggiungere rapidamente la quarta iterazione.

(gdb) b loop
Breakpoint 2 at 0x56556785: file /mnt/c/reti_logiche/assembler/lezioni/1/maiusc.s, line 20.
(gdb) c
Continuing.

Breakpoint 2, loop () at /mnt/c/reti_logiche/assembler/lezioni/1/maiusc.s:20
20 movb (%esi, %ecx), %al
(gdb) i r ecx
ecx 0x0 0
(gdb) c
Continuing.

Breakpoint 2, loop () at /mnt/c/reti_logiche/assembler/lezioni/1/maiusc.s:20
20 movb (%esi, %ecx), %al
(gdb) i r ecx
ecx 0x1 1
(gdb) c
Continuing.

Breakpoint 2, loop () at /mnt/c/reti_logiche/assembler/lezioni/1/maiusc.s:20
20 movb (%esi, %ecx), %al
(gdb) c
Continuing.

Breakpoint 2, loop () at /mnt/c/reti_logiche/assembler/lezioni/1/maiusc.s:20
20 movb (%esi, %ecx), %al
(gdb) i r ecx
ecx 0x3 3
(gdb)

L'ultima operazione base da vedere è osservare valori in memoria. Il comando x sta per examine memory ma, a differenza degli altri comandi, esiste solo in forma abbreviata. Il comando ha 4 argomenti:

  • N, il numero di "celle" consecutive della memoria da leggere;
  • F, il formato con cui interpretare il contenuto di tali "celle", per esempio d per decimale e c per ASCII;
  • U, la dimensione di ciascuna "cella": b per 1 byte, h per 2 byte, w per 4 byte;
  • addr, l'indirizzo in memoria da cui cominciare la lettura.

Il formato del comando è x/NFU addr. Gli argomenti N, F e U sono, di default, gli ultimi utilizzati. Questo è infatti un comando con memoria. Quando non sono specificati, si dovrà omettere anche lo /. L'argomento addr si può passare come

  • costante esadecimale, per esempio x 0x56559066;
  • label preceduta da &, per esempio x &msg_in;
  • registro preceduto da $, per esempio x $esi;
  • espressione basata su aritmetica dei puntatori, per esempio x (int*)&msg_in+$ecx.

L'ultima opzione è abbastanza ostica da sfruttare, vedremo come evitarla con una tecnica alternativa.

Vediamo degli esempi tornando al debugging del nostro primo programma:

(gdb) x/20cb &msg_in
0x56559066: 113 'q' 117 'u' 101 'e' 115 's' 116 't' 111 'o' 32 ' ' 101 'e'
0x5655906e: 39 '\'' 32 ' ' 117 'u' 110 'n' 32 ' ' 116 't' 101 'e' 115 's'
0x56559076: 116 't' 13 '\r' 10 '\n' 0 '\000'
(gdb) x/20cb &msg_out
0x565590b6: 81 'Q' 85 'U' 69 'E' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000'
0x565590be: 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000' 0 '\000'
0x565590c6: 0 '\000' 0 '\000' 0 '\000' 0 '\000'
(gdb) x/20cb $esi
0x56559066: 113 'q' 117 'u' 101 'e' 115 's' 116 't' 111 'o' 32 ' ' 101 'e'
0x5655906e: 39 '\'' 32 ' ' 117 'u' 110 'n' 32 ' ' 116 't' 101 'e' 115 's'
0x56559076: 116 't' 13 '\r' 10 '\n' 0 '\000'

In questo programma usiamo un'indirizzazione con indice per leggere e scrivere lettere nei vettori. Infatti, vediamo che il registro esi punta sempre alla prima lettera del vettore, e abbiamo bisogno di usare anche ecx per sapere qual è la lettera che il programma intende processare in questa iterazione del loop.

Per usare la sintassi menzionata sopra, dovremmo ricordarci come tradurre (%esi, %ecx) in un'espressione di aritmetica dei puntatori. Una alternativa molto agevole è invece la scomposizione dell'istruzione movb (%esi, %ecx), %al in due: una lea e una mov. Infatti, ricordiamo che la lea ci permette di calcolare un indirizzo, anche se con composto con indice, e salvarlo in un registro. Possiamo per esempio scrivere

    lea (%esi, %ecx), %ebx
movb (%ebx), %al

In questo modo, l'indirizzo della lettera da leggere sarà contenuto in ebx, cosa che possiamo sfruttare nel debugger con il comando x/1cb $ebx.

Come ultime indicazioni sul debugger, menzioniamo il comando layout regs, che mostra a ogni passo i registri e il codice da eseguire, e i comandi r, per riavviare il programma e q, per terminare il debugger. Le versioni qq e rr, definite ad hoc nell'ambiente di questo corso, fanno lo stesso senza richiedere conferma.

Domande a risposta multipla

Vedremo ora delle domande a risposta multipla, riguardanti assembler e aritmetica. Prima però un disclaimer, che sembra purtroppo necessario.

Studiare per l'esame, non l'esame

Le domande e gli esercizi dell'esame di Reti Logiche sono pensati perché, con la dovuta conoscenza e comprensione degli argomenti del programma del corso, sia agevole arrivare alla risposta/soluzione. Cioè domanda + conoscenze => risposta.

È invece difficile e controproducente cercare di fare il contrario: non basta fissare domande e risposte per riuscirne a derivare conoscenze di alcun tipo. Anzi, le conoscenze necessarie sono quasi del tutto assenti dal testo delle domande e delle risposte, quelle le trovate nel materiale di studio del corso.

Questo disclaimer è dato nella speranza di scongiurare il frequente caso di studenti che ignorano lezioni, dispense, libri di testo e ricevimenti, cercando invece di trovare autonomamente "strategie più dirette". Ripetendo poi l'esame più e più volte.

15/07/2025, domanda 9

mov 0x00, 0xFF

Nell'architettura x86 l'istruzione scritta sopra:

  1. copia la costante 0x00 (su 8 bit) nella cella di memoria di indirizzo 0xFF
  2. copia il contenuto della cella di memoria di indirizzo 0x00 dentro la cella di indirizzo 0xFF
  3. viene accettata dall'assemblatore solo se completata con un suffisso (b,w,l)
  4. nessuna delle precedenti

Per prima cosa, ricordiamo le sintassi per operandi immediati: con $0x00 si rappresenta il byte 0x00, mentre con solo 0x00 si rappresenta l'indirizzo 0x00. Verrebbe quindi da rispondere b, o c se ci si accorge che non c'è nulla a indicare la dimensione dello spostamento.

Tuttavia il dubbio è inutile, perché la mov nell'architettura x86 non supporta affatto lo spostamento tra un indirizzo di memoria a un altro; serve l'istruzione stringa movs per farlo (che richiede infatti di esplicitare sempre b,w o l). La risposta giusta è quindi la d.

24/06/2025, domanda 9

var0: .byte 0x30, 0x31
var1: .word 0x100, 0x120
var2: .long var0+3

mov var2, %ebx
mov (%ebx), %al

Alla fine del segmento di codice scritto sopra, al contiene

  1. 0x01
  2. 0x20
  3. var0+3
  4. Nessuna delle precedenti

Questo è un genere di esercizio che può trarre in inganno perché la risposta non si trova affatto sempre allo stesso modo.

Ciò che è in comune sono le cose che vanno sapute fare:

  • ricostruire il layout in memoria dei dati, e quindi la corrispondenza tra un indirizzo e il byte corrispondente
  • distinguere le varianti di operandi immediati e forme di indirizzamento
  • distinguere lea e mov

A fini didattici, svolgerò per intero la ricostruzione del layout in memoria, poi guarderò a cosa fa il programma. All'esame, fate il contrario.

Cominciamo dalla prima riga: all'indirizzo var0 c'è il byte 0x30, a var0+1 il byte 0x31.

Passiamo quindi alla seconda riga: deriviamo innanzitutto che, se questi nuovi dati cominciano a var1, allora dev'essere var1 = var0 + 2.

In questa riga i dati sono word, ossia valori scritti su due byte. Ricordiamo che l'architettura è little-endian, ossia little end first: il byte meno significativo viene scritto prima.

Dunque, la word 0x0100 (il primo 0 è implicito) viene suddivisa nei due byte 0x01 e 0x00, e salvati in memoria nell'ordine 0x00 (a var0 + 2) 0x01 (a var0 + 3). Seguono, per la stessa ragione, 0x20 (a var0 + 4) e 0x01 (a var0 + 5).

Infine, alla terza riga abbiamo var2: .long var0+3: questo è il valore di un indirizzo, non il contenuto di un indirizzo. Questo valore verrà calcolato poi dall'assemblatore (o altri... è complicato) in base all'indirizzo a partire dal quale verrà allocata questa sezione .data. Dunque non possiamo prevederne il valore a priori, ma possiamo prevedere che punterà al byte più significativo della prima word in var1: 0x01.

Ora abbiamo un'idea completa di questa sezione .data, e possiamo passare allo svolgimento del programma.

La prima istruzione è mov var2, %ebx, dove il primo operando è un indirizzo immediato. Quello che fa la mov, quindi, è copiare il valore all'indirizzo var2 nel registro %ebx. Dato che %ebx è a 32 bit, tale copia sarà a 32 bit (cioè, è implicitamente una movl). Dunque, %ebx conterrà l'indirizzo var0+3.

La seconda istruzione è mov (%ebx), %al, dove il primo operando è un indirizzamento tramite registro. Quello che fa la mov, quindi, è copiare il valore all'indirizzo contenuto in %ebx in %al. Dato che %al è a 8 bit, questa è implicitamente una movb. Dunque, è 0x01 che viene copiato in %al.

La risposta corretta è la a.

Andare dritti al punto

Per svolgere il ragionamento di sopra, e tutte le sue varianti, c'è bisogno di padroneggiare i pochi concetti elencati sopra.

In un vero contesto d'esame, è consigliato partire dal programma (poche istruzioni) e svolgere direttamente e solo i calcoli richiesti da tale programma. Per esempio, in questo esercizio è stato del tutto inutile discutere dei byte a var0 e della seconda word di var1.

Debugger come strumento di verifica

Il modo più diretto per controllare un esercizio di questo tipo è assemblarlo e vedere con il debugger il contenuto di registri e memoria. Per esempio, con il comando x/4xb &var1 si può verificare che i byte a partire da var1 sono proprio quelli detti sopra.

09/09/2025, domanda 3

add %al, %bl

Dopo l'istruzione riportata sopra, quale delle seguenti configurazioni degli operandi scrive 1 dentro OF, e 1 dentro CF?

  1. al = 0100_0000, bl = 0100_0000
  2. al = 1000_0000, bl = 1000_0000
  3. al = 1111_1111, bl = 0000_0001
  4. Nessuna delle precedenti

Per rispondere, ricapitoliamo come si comportano l'istruzione add e i flag OF e CF.

Si parte dal fatto che la somma di numeri naturali e numeri interi in complemento alla radice (CR) eseguono esattamente le stesse operazioni. Quindi abbiamo una sola istruzione add che va usata sia che gli operandi vadano interpretati come naturali, sia che vadano interpretati come interi.

Per il processore, però, non c'è nulla che indichi se siamo nell'uno o nell'altro caso. Dunque, i flag relativi vengono popolati in ogni caso, e sta a noi controllare i flag giusti in base all'operazione svolta.

Il flag CF viene settato a 1 (0 altrimenti) se la somma, interpretata come somma fra naturali, produce riporto. In altre parole, se il risultato naturale non sta sul numero di bit previsto, in questo caso 8, che può contenere solo naturali tra 0 e 255. Questo è il caso delle risposte b e c: per b abbiamo 128 + 128 = 256, per c 255 + 1 = 256, dove 256 = 1_0000_0000 non sta su 8 bit. Invece, per a abbiamo 64 + 64 = 128, ossia 1000_0000, che sta tranquillamente su 8 bit.

Il flag OF viene settato a 1 (0 altrimenti) se la somma, interpretata come somma fra interi, produce overflow. In altre parole, se il risultato intero non sta sul numero di bit previsto, in questo caso 8, che può contenere solo interi (in CR) tra -128 e +127. Questo è il caso delle risposte a e b: per a abbiamo 64 + 64 = 128, che in CR si rappresenta con 0_1000_0000, per b abbiamo -128 + (-128) = -256, che in CR si rappresenta come 1_0000_000. Invece, per c abbiamo -1 + 1 = 0, ossia 0000_0000.

Dato che la b verifica entrambi i criteri, è la risposta giusta.

Esercizi per casa

Parte fondamentale delle esercitazioni è fare pratica. Per questo, vengono lasciati alcuni esercizi per casa. Le soluzioni di alcuni di questi saranno discusse nelle esercitazioni successive.

Esercizio 1.2: istruzioni stringa

L'esercizio 1.1 compie un'operazione ripetuta su vettori. Legge da un vettore, una cella alla volta, ne manipola il contenuto, poi lo scrive su un altro vettore. Questo genere di operazioni è adatto per l'uso delle istruzioni stringa. Riscrivere quindi il programma sfruttando questo set di istruzioni:

1. Leggere messaggio da terminale.
2. Convertire le lettere minuscole in maiuscolo, usando le istruzioni stringa.
3. Stampare messaggio modificato.

Esercizi 1.3 e 1.4

Scrivere dei programmi che si comportano come gli esercizi 1.1 e 1.2, tranne che per il fatto di convertire da maiuscolo in minuscolo anziché il contrario.

Esercizio 1.5

Scrivere un programma che, a partire dalla sezione .data che segue (e scaricabile qui), conta e stampa il numero di occorrenze di numero in array.

.include "./files/utility.s"

.data
array: .word 1, 256, 256, 512, 42, 2048, 1024, 1, 0
array_len: .long 9
numero: .word 1

Esercizio 1.6

Quello che segue (e scaricabile qui) è un tentativo di soluzione dell'esercizio precedente. Contiene tuttavia uno o più bug. Trovarli e correggerli.

.include "./files/utility.s"

.data
array: .word 1, 256, 256, 512, 42, 2048, 1024, 1, 0
array_len: .long 9
numero: .word 1

.text

_main:
nop
mov $0, %cl
mov numero, %ax
mov $0, %esi

comp:
cmp array_len, %esi
je fine
cmpw array(%esi), %ax
jne poi
inc %cl

poi:
inc %esi
jmp comp

fine:
mov %cl, %al
call outdecimal_byte
ret